Skip to content

feat(db): add experimental db tunnel (MySQL over WebSocket) - #249

Merged
jpage-godaddy merged 7 commits into
godaddy:mainfrom
mcolakovic-godaddy:feat/db-tunnel
Sep 21, 2026
Merged

jpage-godaddy merged 7 commits into
godaddy:mainfrom
mcolakovic-godaddy:feat/db-tunnel

Conversation

@mcolakovic-godaddy

Copy link
Copy Markdown
Contributor

Summary

Adds gddy db tunnel, an experimental streaming command that opens a local TCP port and bridges raw MySQL bytes over a single WebSocket per connection to an application's agent, which in turn dials the application's own database. This lets a developer point any local MySQL client (mysql, TablePlus, DataGrip, an ORM) at a hosted app's database without exposing that database publicly.

Full design and rationale: docs/proposals/db-tunnel.md.

How it works

  • Byte pump, not a proxy. The tunnel never parses the MySQL protocol and never injects credentials. MySQL authentication and TLS are negotiated end-to-end between the local client and the database, so the CLI and the agent see only opaque bytes.
  • One WebSocket per TCP connection. Each accepted local connection opens its own WebSocket; frames are binary and carry raw protocol bytes in both directions.
  • Token mint. The command authenticates the one-time token mint with the CLI's own OAuth credential, then receives a short-lived, app-scoped token and the agent URL from the hosting API and presents that token to the agent. No long-lived secrets are stored.
  • Stage-gated. The db group is registered behind the experimental stage and hidden at the GA default, so it does not appear in normal help output.

Security

  • No credential injection or interception — DB auth and TLS are end-to-end between client and database.
  • A short-lived, app-scoped mint token is obtained per invocation via the existing OAuth credential, presented to the agent, and never persisted.
  • The local listener binds to loopback.

Testing

  • cargo fmt --check, cargo clippy -- -D warnings, cargo test (757 tests), cargo check --locked, and the module-size check all pass.
  • Unit tests cover WebSocket URL construction (ws/wss, with and without an explicit port) and the token-mint request/response contract.

Notes

  • This is the client half of the feature. The server-side token mint and the agent's WebSocket handler are delivered separately and are not required for this change to build or ship — the command is inert until those are available and the experimental stage is enabled.

Add `gddy db tunnel`, an experimental streaming command that opens a local
TCP port and bridges raw MySQL bytes over a single WebSocket per connection
to an application's agent, which dials the app's own database. The tunnel is
a byte pump: it never parses MySQL and never injects credentials, so MySQL
auth and TLS are negotiated end-to-end between the client and the database.

The command authenticates the one-time token mint with the CLI's own OAuth
credential, receives a short-lived app-scoped token and the agent URL from
the hosting API, and presents that token to the agent. The `db` module is
gated behind the experimental stage and hidden at the GA default.

Includes the public proposal at docs/proposals/db-tunnel.md.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Copilot AI lite review requested due to automatic review settings September 4, 2026 16:40

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Changes recommended

The WebSocket URL builder can generate invalid URLs for IPv6 agent hosts (missing required brackets), which can break connections in valid environments.

Once you've addressed the issues Copilot identified, you can request another Copilot review.

Pull request overview

Adds an experimental gddy db tunnel command group for tunneling MySQL traffic from a local TCP listener to a hosted app’s agent via per-connection WebSockets, plus the hosting API client support needed to mint short-lived agent tokens.

Changes:

  • Registers a new experimental db command group and adds CLI-stage gating tests.
  • Implements db tunnel as a streaming command that relays raw bytes between TCP and WebSocket connections.
  • Extends the hosting Node.js client with an agent-token mint endpoint and associated unit test; adds required Rust dependencies and a design proposal doc.
File summaries
File Description
rust/src/main.rs Wires in the db module and adds a gating/help-surface test for db tunnel.
rust/src/hosting/nodejs/client.rs Adds get_agent_token API call and a unit test for request/response shape.
rust/src/db/tunnel.rs Implements the streaming tunnel command, URL derivation, token minting, and byte relay.
rust/src/db/mod.rs Adds the experimental db group wiring and registers db tunnel.
rust/Cargo.toml Adds futures-util and tokio-tungstenite dependencies for WS + stream utilities.
rust/Cargo.lock Locks new transitive dependencies for tungstenite/rustls stack.
docs/proposals/db-tunnel.md Documents the design, security model, and rationale for db tunnel.
Review details
  • Files reviewed: 6/7 changed files
  • Comments generated: 1
  • Review effort level: Lite

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

Comment thread rust/src/db/tunnel.rs

@jpage-godaddy jpage-godaddy left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This looks great.

mcolakovic-godaddy and others added 2 commits September 16, 2026 20:44
- Refuse to bind a non-loopback interface unless the operator passes the
  new --allow-non-loopback flag. The local port relays straight to the
  app's live database with no authentication of its own, so it must not be
  put on the network by accident. Fail closed before a token is minted, and
  treat any host we cannot prove is loopback -- including a bare hostname we
  would have to resolve -- as non-loopback.
- Emit an explicit warning that the tunnel targets the app's live database,
  and a second warning whenever a non-loopback bind is actually in effect.
- Withhold secret-bearing response bodies from the `--debug transport`
  trace. The agent-token response carries a bearer token, and the transport
  logger prints response bodies verbatim (only sensitive headers are
  redacted), so route that one call through a body-suppressing path. A
  minted token can no longer reach the debug output; status and headers
  still log.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
… to TLS

The experimental `db tunnel` command now steps its OAuth credential up to a
dedicated hosting.database:tunnel scope in addition to
hosting.paas.deploy:execute, so authority to publish a deployment does not by
itself grant raw database read/write. The scope is declared in the registry
(default off) and presented at the mint call; the edge enforces both scopes.

Because the tunnel forwards bytes without terminating TLS, the command's long
help, its connect hint, and the proposal now steer operators to connect their
MySQL client with TLS enabled (for example --ssl-mode=REQUIRED) so the local
hop is encrypted as well, and note the database may compel it with
require_secure_transport=ON.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
mcolakovic-godaddy and others added 3 commits September 18, 2026 15:51
Reconcile the experimental db-tunnel work with main's hosting module reorg:

- main.rs: keep `mod db;` / `db::module()` alongside the renamed `api`
  module and drop the now-folded `contacts` module.
- scopes.rs: re-apply HOSTING_DATABASE_TUNNEL on top of main's renamed
  hosting scope constants and SCOPE_REGISTRY entries.
- hosting/client.rs + client_tests.rs: carry `get_agent_token` and its
  secret-response redaction onto main's flattened `/v1/hosting` client; the
  mint keeps its explicit `/nodejs` path segment.
- db/tunnel.rs: retarget imports at the relocated `crate::http` and
  `crate::hosting::client`.
- Remove the orphaned `hosting/nodejs` subtree deleted on main.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
build_tunnel_ws_url mapped `http|ws` to plain `ws`. Nothing can reach that
branch any more, and it is a latent cleartext-downgrade path, so drop it and
admit only `https|wss`.

The agent URL is service-supplied: it comes only from the mint response
(`app.urls.agent`, read verbatim from mgmt-airo), and `--agent-url` was removed
before this shipped. In production the data plane is CLI -> Cloudflare (TLS) ->
v2-ingress-proxy -> agent `:80`, where the plaintext listener sits inside the
cell and is reachable from ingress nodes only; the host in `agentUrl` is always
the Cloudflare-fronted https name. The one consumer of the `http` branch was
agents/scripts/db-tunnel-local.ts, a throwaway harness since deleted from
airo-app-builder.

Nothing validates the scheme upstream either -- the mint response schema is
`agentUrl: z.string()`, trimmed and checked non-empty only -- so a
mis-provisioned or tampered record would have silently put the agent JWT and
every MySQL byte on the wire in the clear (CWE-319). Rejecting instead fails the
run with a clear error before any port is bound. It also makes the CLI's
`rustls-tls-webpki-roots` posture unconditional: a vendored root set does
nothing when the scheme is not `wss` at all.

The `https`->`wss` mapping itself has to stay -- tungstenite's
`into_client_request` accepts only `ws`/`wss`.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Conflict in rust/src/hosting/client.rs: main (godaddy#276, godaddy#278) replaced the
hand-rolled request helpers with the generated `hosting-client` crate, while this
branch had added `send_json_secret_response` -- a variant that withholds the
response body from the `--debug transport` trace so the agent-token mint cannot
log a minted bearer token (DBT-10).

Resolution keeps main's refactor and preserves that redaction:

- Dropped `send_json` / `send_json_secret_response` / `send_json_inner`. After
  the refactor every caller goes through `api()`, `send_patch` or
  `post_empty_json`, so they had no remaining users.
- Kept the `REDACTED_RESPONSE_BODY` marker and moved the redaction onto
  `post_empty_json`, which is the helper `get_agent_token` now needs: split into
  `post_empty_json_inner(path, log_response_body)` behind `post_empty_json` and
  `post_empty_json_secret_response`. `create_deployment` keeps the logging
  variant; only the mint redacts.
- `get_agent_token` now calls `post_empty_json_secret_response`, preserving its
  wire contract (POST, empty JSON body, bearer auth) -- pinned by the unchanged
  `get_agent_token_posts_empty_body_and_returns_url_and_token` test, which still
  passes.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Comment thread rust/src/scopes.rs Outdated
The `db tunnel` command declared `hosting.database:tunnel`, which was
never a real, registered OAuth scope. Rename the constant and value to
`hosting.database.tunnel:execute` — the scope now registered in AuthZ —
so the CLI requests a grantable scope at OAuth step-up.

This is a dedicated grant, separate from `hosting.deployment:execute`:
authority to publish a deployment does not by itself grant raw database
read/write, so opening a MySQL-over-WebSocket tunnel needs its own scope.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@jpage-godaddy
jpage-godaddy merged commit 3b16c6b into godaddy:main Sep 21, 2026
5 checks passed
@daleksic-godaddy
daleksic-godaddy deleted the feat/db-tunnel branch September 23, 2026 08:04
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants